Skip to content

fix(data): searchFields / groupBy / aggregations 指向不存在的字段时被拒绝,而不是静默降级 (#4254) - #4315

Merged
os-zhuang merged 4 commits into
mainfrom
claude/rest-read-path-field-degradation-4240d5
Jul 31, 2026
Merged

fix(data): searchFields / groupBy / aggregations 指向不存在的字段时被拒绝,而不是静默降级 (#4254)#4315
os-zhuang merged 4 commits into
mainfrom
claude/rest-read-path-field-degradation-4240d5

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Closes #4254

TL;DR

#4226(PR #4240)把 sort / select / expand 收口后,同一台机器在剩下三条点名字段的轴上继续漏气。三条现在都在共享的 normalizerfindData)里被拒绝,于是 GET /data/:objectPOST /data/:object/query、export 路由和 runtime dispatcher 给出同一个答案:

search=alpha&searchFields=no_such -> 之前 200 + 比收窄更多的行     现在 400 INVALID_FIELD
groupBy=[no_such]                 -> 之前 200 + N 组塌成 1 组       现在 400 INVALID_FIELD
sum(no_such)                      -> 之前 200 + 求和得 0            现在 400 INVALID_FIELD

三条轴分别怎么改的

searchFields400 INVALID_FIELD,三段式消息

issue 点名要与 #4226expand 的三段式同构,落地为三条消息,因为修法不同:

  1. 根本不是字段 —— 请求里的拼写错误。带 Did you mean 建议;点号路径(parent_id.title)单独提示「search 只扫本对象自己的列」,因为引擎按精确名求交集,头段校验会把回退兜回来。
  2. 是字段但不可搜索 —— 修的是对象。消息按 allowed 集的来源分两支:声明了 searchableFields 的对象指向声明本身;auto-default 的对象说明该字段被排除的原因(类型 / 系统列 / hidden)并给出「声明 searchableFields」的出路。
  3. searchableFields 里声明了、字段却不存在 —— 陈旧声明,bug 在对象上。单独一条消息是因为 objectui 的列表搜索把 schema.searchableFields 原样回声$searchFieldsListView.tsx),把它报成「调用方拼错」会让人去修根本没写错的请求。仍然是 400:全部请求名都陈旧时,引擎的回退会扫默认全集 —— 正是本轴要堵的「要收窄、结果放宽」。

两支(全未知 / 部分未知)都拒,与 issue 的裁决一致。allowed 集由 resolveSearchFieldResolution 解析 —— 该函数从 objectql 移入 @objectstack/spec/datasearch-fields.ts),引擎的 search 展开和这个 gate 消费同一份实现,gate 不可能放行一个引擎会丢弃的名字,也不可能拒掉一个引擎会扫的(#4240REFERENCE_VALUE_TYPES 堵 expand 漂移的同一手法)。引擎侧 resolveSearchFields 的容忍未动(内部调用方不经过 ingress)。

覆盖引擎实际读取的每种拼写searchFields / $searchFields(逗号串与数组)以及 search: { query, fields } 对象形态(数组与逗号串——第二种是复查引擎时发现的:engine.findrequestedFields 也消费字符串形态,只按 Array.isArray 镜像会留下缺口)。报错引用调用方真正写下的参数名。

groupBy400 INVALID_FIELD

in-memory 回退路径把未知列对每行投影成 undefined ?? null,所有行进同一个桶:[{no_such: null, n: 3}] —— n 是真实行数,结构完全合法,图表照画一根柱子。SQL 原生路径则把 GROUP BY no_such 交给数据库(SqlDriver 是否吞错未定)——issue §4 点名的「两条路径可能给出相反答案」,在共享 ingress 收口后两条路径先给出同一个 400。字符串与结构化 {field, dateGranularity} 两种形态都校验;按精确名判定(分组语义就是本对象的列)。

aggregations400 INVALID_FIELD

sum(<拼错>) 把一列 undefined 折成 0,和真实的「本季度 0」同形;avg/min/maxnull 同理。aggregations[].field 逐项校验;countfield(或 '*' 哨兵)是唯一合法的无字段形态,放行。

顺带:无法读取的形态 → 400 INVALID_QUERY(catalog 里首个 emitter)

groupBy: "status"(裸字符串)、[42]{dateGranularity:'month'}(没有 field)、枚举外的 function / dateGranularity、缺 alias —— 每一种此前要么被 Array.isArray 路由守卫忽略(行未分组原样返回),要么算出静默占位值(null 结果、键名 "undefined" 的列、未知粒度下一行一桶)。INVALID_QUERY("Malformed query syntax")自写进标准 catalog 起没有任何 emitter —— 与 #4240 启用 INVALID_SORT 同一姿势。形态检查不依赖 registry(分层里 legacy/registry-less 宿主也拒形态,只跳过字段名检查),与 #4196 投影形态检查同序。

分层与边界

测试

packages/objectql/src/query-expression-conformance.test.ts 新增 #4254 describe 块(37 个用例,全文件 77 个全绿),照搬 #4240 的纪律:

  • 每条轴都有对照组,且这次对照组要求测试驱动真的执行:共享 memory driver 补了 $or / $contains 求值(否则任何 search 都全命中,「searchFields 真的收窄了行集」的断言 vacuously 绿),聚合用例删掉 driver 的 aggregate stub 走引擎真正的 in-memory 回退(issue 实测的那条路径)。
  • issue transcript 逐条落地:search=a 命中 title 与 notes 各一行 → searchFields=title 收到一行 → searchFields=no_such 400(此前是两行);groupBy=[status] 真分两组 → [no_such] 400(此前一桶);sum(estimate) 真合计 → sum(no_such) 400(此前 0)。
  • 另有:五种 override 拼写同答案并引用原参数名、声明式与 auto-default 两支的「不可搜索」消息、陈旧声明(objectui 回声形)、日期分桶对照与非法粒度、count(*) 两种拼写放行、七轴合成请求、404 优先、legacy 数组字段表只降级字段名检查。
  • 全仓 pnpm test132/132 任务绿(含 dogfood HTTP 级 430 例)。check:generated(api-surface 已重生成)、check:livenesscheck:exported-any 均绿。

文档

data-api.mdx#4240 的「Nor is a sort…」旁新增三轴一节(请求 → 结果对照表 + 各轴为何要紧);参数表补 search / searchFields 行;error-catalog.mdxINVALID_QUERY 从占位描述改为写明其 emitter,INVALID_FIELD 列全七条轴;queries.mdx / query-syntax.mdx 在聚合与搜索小节各加 ingress 行为 callout。search 会格 ledger(search-conformance.ledger.ts)的 enforcement 指针随实现迁移更新。

对调用方的影响

  • 点名真实字段的请求不受影响。
  • searchFields / groupBy / aggregations[].field 中点名不存在字段的请求现在显式失败,而不是收到一个被放宽 / 未分组 / 合计为 0 的 200。
  • 一个已知的边缘:对象的 searchableFields 声明里若有陈旧条目(字段后来改名),objectui 回声该声明的列表搜索会开始收到 400(消息直指对象与修法)。配套的 authoring-time lint(searchableFields ⊆ fields)已作为后续任务另开。

关联


Generated with Claude Code

os-zhuang and others added 2 commits July 31, 2026 13:24
…ld are rejected, not silently degraded (#4254)

#4226 closed sort / select / expand; the same machine kept leaking on the
remaining three field-naming read axes, and each failure corrupted something
the closed axes never touched:

    search=alpha&searchFields=no_such -> 200  MORE rows than the narrowing allowed
    groupBy=[no_such]                 -> 200  [{no_such: null, n: <true count>}]
    sum(no_such)                      -> 200  0 - indistinguishable from a real zero

Each is now refused at the shared normalizer (findData), so the list route,
POST /data/:object/query, the export route and the runtime dispatcher give one
answer instead of four.

- searchFields -> 400 INVALID_FIELD. The select failure with the sign flipped
  outward: dropped unknown names emptied the override, which fell back to the
  FULL searchable set - a narrowing parameter that widened, changing which
  ROWS came back. Three messages (typo / real-but-unsearchable / stale
  searchableFields declaration), because the fixes differ. The allowed set is
  resolved by the same spec/data function the engine's search expansion
  consumes (resolveSearchFieldResolution, moved from objectql), so gate and
  engine cannot drift.
- groupBy -> 400 INVALID_FIELD. The in-memory fallback projected an unknown
  column as null for every row: N groups collapsed into one null-keyed bucket
  carrying the true row count.
- aggregations -> 400 INVALID_FIELD. sum(<typo>) folded undefined to 0;
  avg/min/max answered null. count with no field (or '*') stays legal.
- Unreadable SHAPES on the aggregation axes -> 400 INVALID_QUERY - the
  catalog code that had no emitter, like INVALID_SORT before #4226.

Tiering mirrors #4226 (no registry / no field map / legacy array map -> name
gates skip; shape gates still apply). Engine tolerance for internal callers is
untouched. @objectstack/rest stops logging INVALID_FILTER / INVALID_SORT /
INVALID_QUERY rejections as unhandled errors.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Jul 31, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Jul 31, 2026 6:58am

Request Review

@github-actions github-actions Bot added documentation Improvements or additions to documentation protocol:data tests tooling size/xl labels Jul 31, 2026
@github-actions

github-actions Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 5 package(s): @objectstack/metadata-protocol, @objectstack/objectql, @objectstack/dogfood, @objectstack/rest, @objectstack/spec.

112 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/ai/agents.mdx (via @objectstack/spec)
  • content/docs/ai/connect-mcp.mdx (via @objectstack/rest)
  • content/docs/ai/skills-reference.mdx (via @objectstack/spec)
  • content/docs/ai/skills.mdx (via @objectstack/spec)
  • content/docs/api/client-sdk.mdx (via @objectstack/spec)
  • content/docs/api/environment-routing.mdx (via @objectstack/spec)
  • content/docs/api/error-catalog.mdx (via @objectstack/spec)
  • content/docs/api/error-handling-client.mdx (via @objectstack/spec)
  • content/docs/api/error-handling-server.mdx (via @objectstack/rest, @objectstack/spec)
  • content/docs/api/index.mdx (via @objectstack/rest, @objectstack/spec)
  • content/docs/automation/approvals.mdx (via @objectstack/spec)
  • content/docs/automation/connectors.mdx (via @objectstack/spec)
  • content/docs/automation/flows.mdx (via @objectstack/spec)
  • content/docs/automation/hook-bodies.mdx (via packages/spec)
  • content/docs/automation/hooks.mdx (via @objectstack/spec)
  • content/docs/automation/index.mdx (via @objectstack/spec)
  • content/docs/automation/webhooks.mdx (via @objectstack/spec)
  • content/docs/automation/workflows.mdx (via @objectstack/spec)
  • content/docs/concepts/architecture.mdx (via @objectstack/spec)
  • content/docs/concepts/design-principles.mdx (via packages/spec)
  • content/docs/concepts/index.mdx (via @objectstack/spec)
  • content/docs/concepts/metadata-driven.mdx (via @objectstack/spec)
  • content/docs/concepts/metadata-lifecycle.mdx (via @objectstack/metadata-protocol, @objectstack/objectql, packages/spec)
  • content/docs/concepts/north-star.mdx (via packages/spec)
  • content/docs/data-modeling/analytics.mdx (via @objectstack/spec)
  • content/docs/data-modeling/drivers.mdx (via @objectstack/spec)
  • content/docs/data-modeling/external-datasources.mdx (via @objectstack/spec)
  • content/docs/data-modeling/field-types.mdx (via @objectstack/spec)
  • content/docs/data-modeling/fields.mdx (via @objectstack/spec)
  • content/docs/data-modeling/formulas.mdx (via packages/objectql, @objectstack/spec)
  • content/docs/data-modeling/index.mdx (via @objectstack/spec)
  • content/docs/data-modeling/objects.mdx (via @objectstack/spec)
  • content/docs/data-modeling/queries.mdx (via @objectstack/spec)
  • content/docs/data-modeling/schema-design.mdx (via @objectstack/spec)
  • content/docs/data-modeling/seed-data.mdx (via @objectstack/spec)
  • content/docs/data-modeling/validation-rules.mdx (via @objectstack/spec)
  • content/docs/data-modeling/validation.mdx (via @objectstack/spec)
  • content/docs/deployment/cli.mdx (via @objectstack/spec)
  • content/docs/deployment/migration-from-objectql.mdx (via @objectstack/objectql)
  • content/docs/deployment/troubleshooting.mdx (via @objectstack/spec)
  • content/docs/deployment/validating-metadata.mdx (via @objectstack/spec)
  • content/docs/deployment/vercel.mdx (via @objectstack/objectql)
  • content/docs/getting-started/build-with-claude-code.mdx (via @objectstack/spec)
  • content/docs/getting-started/common-patterns.mdx (via @objectstack/spec)
  • content/docs/getting-started/examples.mdx (via @objectstack/spec)
  • content/docs/getting-started/quick-reference.mdx (via @objectstack/spec)
  • content/docs/getting-started/quick-start.mdx (via @objectstack/spec)
  • content/docs/getting-started/your-first-project.mdx (via @objectstack/spec)
  • content/docs/kernel/cluster.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/auth-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/cache-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/data-engine.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/index.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/metadata-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/storage-service.mdx (via packages/spec)
  • content/docs/kernel/index.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/email-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/index.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/queue-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/sharing-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/sms-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/storage-service.mdx (via packages/spec)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/metadata-protocol, @objectstack/objectql, @objectstack/spec)
  • content/docs/kernel/services.mdx (via @objectstack/objectql, @objectstack/spec)
  • content/docs/permissions/authentication.mdx (via @objectstack/objectql, @objectstack/rest)
  • content/docs/permissions/authorization.mdx (via packages/qa/dogfood, @objectstack/spec)
  • content/docs/permissions/delegated-administration.mdx (via packages/qa/dogfood)
  • content/docs/permissions/permission-sets.mdx (via @objectstack/spec)
  • content/docs/permissions/permissions-matrix.mdx (via @objectstack/spec)
  • content/docs/permissions/positions.mdx (via @objectstack/spec)
  • content/docs/permissions/rls.mdx (via @objectstack/spec)
  • content/docs/permissions/sharing-rules.mdx (via @objectstack/spec)
  • content/docs/plugins/adding-a-metadata-type.mdx (via @objectstack/spec)
  • content/docs/plugins/development.mdx (via @objectstack/spec)
  • content/docs/plugins/index.mdx (via @objectstack/objectql, @objectstack/rest, @objectstack/spec)
  • content/docs/plugins/packages.mdx (via @objectstack/objectql, @objectstack/rest, @objectstack/spec)
  • content/docs/protocol/backward-compatibility.mdx (via @objectstack/spec)
  • content/docs/protocol/diagram.mdx (via packages/spec)
  • content/docs/protocol/kernel/config-resolution.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/i18n-standard.mdx (via packages/rest, @objectstack/spec)
  • content/docs/protocol/kernel/index.mdx (via @objectstack/objectql, @objectstack/spec)
  • content/docs/protocol/kernel/lifecycle.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/plugin-spec.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/runtime-capabilities.mdx (via @objectstack/spec)
  • content/docs/protocol/knowledge.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/index.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/query-syntax.mdx (via packages/objectql, @objectstack/spec)
  • content/docs/protocol/objectql/schema.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/security.mdx (via packages/spec)
  • content/docs/protocol/objectql/state-machine.mdx (via @objectstack/objectql, @objectstack/spec)
  • content/docs/protocol/objectui/actions.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/concept.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/index.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/layout-dsl.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/record-alert.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/widget-contract.mdx (via @objectstack/spec)
  • content/docs/releases/implementation-status.mdx (via @objectstack/objectql, @objectstack/rest, @objectstack/spec)
  • content/docs/releases/index.mdx (via @objectstack/spec)
  • content/docs/releases/v12.mdx (via @objectstack/rest, @objectstack/spec)
  • content/docs/releases/v13.mdx (via @objectstack/spec)
  • content/docs/releases/v16.mdx (via @objectstack/spec)
  • content/docs/releases/v17.mdx (via @objectstack/spec)
  • content/docs/releases/v9.mdx (via @objectstack/metadata-protocol, @objectstack/spec)
  • content/docs/ui/actions.mdx (via @objectstack/spec)
  • content/docs/ui/create-vs-edit-form.mdx (via @objectstack/spec)
  • content/docs/ui/dashboards.mdx (via @objectstack/spec)
  • content/docs/ui/forms.mdx (via @objectstack/spec)
  • content/docs/ui/index.mdx (via @objectstack/spec)
  • content/docs/ui/public-data-collection.mdx (via @objectstack/spec)
  • content/docs/ui/setup-app.mdx (via @objectstack/spec)
  • content/docs/ui/translations.mdx (via @objectstack/spec)
  • content/docs/ui/views.mdx (via @objectstack/spec)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

…-field-degradation-4240d5

# Conflicts:
#	packages/metadata-protocol/src/protocol.ts
@os-zhuang
os-zhuang merged commit af2a095 into main Jul 31, 2026
25 of 27 checks passed
@os-zhuang
os-zhuang deleted the claude/rest-read-path-field-degradation-4240d5 branch July 31, 2026 07:35
os-zhuang pushed a commit that referenced this pull request Aug 1, 2026
…ECORD_NOT_FOUND, not 200 (#4435)

The READ path was already honest — `getData` on an unknown id answers `404
RECORD_NOT_FOUND`. Both single-record WRITE paths reported success for a record
that does not exist:

  PATCH  /data/showcase_task/definitely_not_a_row  → 200 {"record":null}
  DELETE /data/showcase_task/definitely_not_a_row  → 200 {"success":true}

REST is a pass-through here (`res.json(await p.deleteData(...))`), so these are
the protocol's answers and this is where they are fixed.

What it cost: a client that PATCHed a concurrently deleted record was told the
write landed, and had to null-check a SUCCESS payload to find out otherwise;
`DELETE` said `success: true` for any string in the path, so a typo'd id, an
already-deleted row and a real deletion were indistinguishable — including in
bulk, where `deleteMany {"ids":["nonexistent_1"]}` answered `succeeded: 1`. It
is the same silent-no-op shape the v17 train removed everywhere else this
window (#4240/#4303/#4315, #4169, #4190), one level up.

- `updateData` asks existence BEFORE the write, via the same `findOne` +
  caller context `getData` uses. Deliberately not a post-check on the returned
  row: the engine returns the post-write READBACK, which is also `null` when
  the row still exists but the write moved it out of the caller's row scope
  (reassigning `owner_id` away from yourself under an owner-scoped policy) —
  reading that as "not found" would 404 a write that succeeded.
- `deleteData` and `deleteManyData` read the driver's own answer. The contract
  (`IDataDriver.delete` — "True if deleted, false if not found") already
  carried it; the code discarded it and pushed a literal `success: true`.
  Read as `=== false` on purpose: that is the contract's positive not-found
  value, while a driver returning the deleted row or an off-contract
  `undefined` gives no such signal, and inventing a 404 from a falsy return
  would break deletes against third-party drivers instead of reporting
  honestly. `success` on the 200 now means what it says.
- The 404 envelope is extracted as `recordNotFoundError` so the read and the
  two write paths cannot drift apart again.

Note on the issue's second half: the spec's `DeleteDataResponseSchema` declares
`success`, not `deleted`, so the existing key is correct as-is and nothing
renames.

Tests: new `protocol.record-not-found.test.ts` (12) covers PATCH/DELETE/
deleteMany, the read/write agreement on the same id, delete-twice, mixed
batches, the `=== false` reading, and that the existence probe is asked with
the caller's context. Three `protocol.dropped-fields.test.ts` fixtures stubbed
`findOne → null` while PATCHing — under the new contract that IS a 404, so they
now describe an engine that has the row (they are about the strip channel, not
about missing records). Suites green: metadata-protocol 169, rest + objectql
unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017gEHJN2NFpS9VMeURvakgD
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Aug 2, 2026
…ck-ai#4437) (objectstack-ai#4494)

* wip: analytics record-level scoping (objectstack-ai#4467) + measure field validation (objectstack-ai#4437)

Two of the three v17 verification defects on the analytics query path.
Both reproduced live on a showcase dev server before the change and
re-verified after; regression tests still to be added (hence wip).

objectstack-ai#4467 — /analytics/query ignored record-level scoping
`ISecurityService.getReadFilter` documents itself as "the same filter the
engine middleware AND-s into every find", exposed for paths that bypass
the middleware (the analytics raw-SQL path has no other source of scope).
That middleware chain is TWO siblings: plugin-security's RLS injection and
plugin-sharing's owner/share visibility filter. Only the RLS half was ever
computed, so the analytics path ran with no owner predicate at all.

Live repro (showcase, `showcase_private_note` sharingModel:'private',
admin owns 5, member holds 2 shares and no viewAllRecords):

  GET  /data/showcase_private_note        member -> total 2   correct
  POST /analytics/query {measures:[count]} member -> count 5   LEAK
  ... + dimensions:["title"]               member -> all 5 titles

getReadFilter now resolves plugin-sharing's buildReadFilter through the
late-bound `sharing` service and AND-composes it with the RLS filter, and
computes the ADR-0057 D1 `__readScope` depth the middleware normally
stashes on the context (no middleware runs on this path). Resolved for
every non-system caller ahead of the RLS branches — none of the RLS
stand-downs is a reason to drop a sibling middleware's predicate — and a
resolution failure denies rather than emitting unscoped SQL.

objectstack-ai#4437 — a measure naming a missing field 500'd with SQLITE_ERROR
`inferMeasure('ghost_sum')` built `SUM(ghost)` with no way to know the
field exists; the driver threw `no such column` and the caller got
`500 {"code":"SQLITE_ERROR","message":"Internal server error"}` — a driver
error class on the wire for a plain typo (ADR-0112). The DATA route has
refused the same mistake with a 400 naming the field since objectstack-ai#4315/objectstack-ai#4254.

`ensureCube` now validates each measure's resolved source field against the
backing object's field names before any SQL is built, and rejects with the
same envelope the data route uses (400 INVALID_FIELD + field/object/param).
Gated the same way as the objectstack-ai#3867 inference gate: only for a cube whose `sql`
is a bare object name, only when the new `getObjectFieldNames` probe answers,
and only for measures whose source is a bare column (count(*) and dotted
cross-object references pass through). Validation runs before the cube is
registered so a rejected query leaves no trace in the registry.

Refs objectstack-ai#4467, objectstack-ai#4437

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017gEHJN2NFpS9VMeURvakgD

* test: pin the analytics scoping + measure-field gates (objectstack-ai#4467, objectstack-ai#4437)

Regression cases for the two fixes in the previous commit, plus a polish to
the objectstack-ai#4437 rejection message.

objectstack-ai#4467 — `security-plugin.test.ts` gains an OWD/sharing block under the
existing `getReadFilter service` describe: AND-composition with the RLS
filter, the sharing predicate surviving alone when RLS contributes nothing,
the ADR-0057 D1 `__readScope` depth being passed (no middleware runs on this
path to stash it), fail-closed on a sharing-resolution throw, the isSystem
bypass, and a deployment without plugin-sharing being unaffected. The
harness gains an optional `sharing` service double.

objectstack-ai#4437 — a new `measure-source-field-gate.test.ts` covering the 400 envelope
and its `field`/`object`/`param`/`measure` members, the dotted `total.sum`
spelling, registry non-poisoning, every legitimate measure spelling still
running, an authored cube whose declared measure lost its field, and the
three stand-downs (no probe, an object the probe cannot describe, and a cube
whose `sql` is an expression rather than an object name). A dotted
cross-object measure is asserted to reach the STRATEGY — the layer that owns
that decision — rather than being reported as a missing column here.

Polish: the rejection listed the caller's own typo as a valid alternative on
the auto-inference path, because `cube.measures` there was inferred from the
very query being rejected. The suggestion list now excludes measures that
failed the check, and names the object's known fields.

Refs objectstack-ai#4467, objectstack-ai#4437

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017gEHJN2NFpS9VMeURvakgD

* chore: add changeset for the analytics scoping + measure-field fixes (objectstack-ai#4467, objectstack-ai#4437)

Both packages are publishable and both changes are observable on a public
surface, so this is a real changeset rather than an empty one.

Levelled `minor` on both counts. objectstack-ai#4467 narrows a public read surface —
analytics results a principal could previously read they now cannot, so
counts drop and `dimensions` groupings lose rows for non-superuser callers
on owner-private objects. objectstack-ai#4437 changes the response envelope for a
caller-shaped mistake (500 SQLITE_ERROR → 400 INVALID_FIELD), which any
caller branching on `error.code` will observe. Neither changes an API
signature: `ISecurityService.getReadFilter`'s declaration is untouched, and
the implementation merely started honouring the contract it already
documented.

Refs objectstack-ai#4467, objectstack-ai#4437

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017gEHJN2NFpS9VMeURvakgD

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Aug 2, 2026
…bjectstack-ai#4435, objectstack-ai#4436, objectstack-ai#4483) (objectstack-ai#4496)

* fix(spec): the $search auto field set's lead ORDERS the set, it must not admit one (objectstack-ai#4483)

`autoDefaultFields` filtered every field through three exclusions
(`SEARCH_AUTO_EXCLUDED_FIELDS`, `hidden`, unsearchable type) and then
prepended the display/name/title field on an EXISTENCE check alone — so the
exclusions did not hold for whichever field happened to lead, and the module's
own "system / audit / heavy fields never auto-included" invariant was false.

Not a contrived shape: ADR-0079's `provisionPrimary(schema, { synthesize:
false })` designates `nameField` at registration, and on a table whose only
textual column IS the primary key (system tables, junction tables, append-only
logs) it designates `id`. `$search` then expanded to `{ id: { $contains: term } }`
— a substring scan over the primary key, returning a narrow and semantically
wrong row set.

It loosened a second layer too: `resolveSearchFieldResolution` is also the
objectstack-ai#4254 REST ingress gate's arbiter for "would the engine actually scan this
field", so with `id` in `allowed` a `$searchFields=id` override was ACCEPTED
rather than refused.

The lead's job is to put the primary title FIRST, never to admit it, so it is
now chosen from the already-filtered set. An excluded / hidden / unsearchable
display field simply does not lead and the set is unchanged; an eligible one
still leads, so the ordering intent is intact.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017gEHJN2NFpS9VMeURvakgD

* wip(drivers): give the uncompilable-filter refusal an ADR-0112 code and drop the driver prefix (objectstack-ai#4436)

IN PROGRESS — code change complete, regression test not yet written and the
real-boot curl repro not yet run.

A filter carrying an operator the driver cannot compile is already REFUSED
rather than silently matched (objectstack-ai#4209/objectstack-ai#4029), but the refusal had no wire
identity: the thrown `Error` carried no `code`, so `mapDataError`'s default
branch served `{"error": "[sql-driver] Unsupported filter operator …"}` — no
`error.code` at all, breaking the ADR-0112 contract every sibling rejection on
the same route already honours (`INVALID_FIELD`, `INVALID_FILTER`,
`RECORD_NOT_FOUND`), and leaking the `[sql-driver]` internal prefix that the
objectstack-ai#3867 sanitiser exists to keep off the wire.

Both drivers now throw through a local `unsupportedFilterError` that stamps
`code = StandardErrorCode.enum.INVALID_FILTER` (the same catalogued code
`metadata-protocol` emits when a filter fails to parse upstream — one
condition, one wire code however the caller reached it) and `status = 400`,
which also puts the rejection on `isExpectedQueryRejection` so a client mistake
stops being logged as an unhandled server error. The internal prefix is gone
from the message; the actionable operator/field/vocabulary detail stays.

Applied to every filter-COMPILATION refusal in both backends, not just the one
branch the issue names — they are the same envelope defect on adjacent lines,
and objectstack-ai#3948 made the two drivers agree that an uncompilable filter is a refusal,
so their refusal envelopes have to agree too.

TODO: regression tests (driver-sql, driver-memory, REST envelope) + boot repro.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017gEHJN2NFpS9VMeURvakgD

* fix(drivers): the uncompilable-filter refusal speaks INVALID_FILTER, without the driver prefix (objectstack-ai#4436)

Completes the WIP commit: adds the remaining sql-driver throw sites and the
regression tests for both backends.

objectstack-ai#4209/objectstack-ai#4029/objectstack-ai#3948 settled the POSTURE — a filter carrying an operator the
driver cannot compile is refused instead of silently matching every row. What
was missing is the refusal's IDENTITY on the wire. The driver threw a bare
`Error`, so `mapDataError` fell through to its default branch and served a body
whose only key was `error`:

  GET /api/v1/data/showcase_task?filter={"title":{"$bogusop":"x"}}
  → 400 {"error":"[sql-driver] Unsupported filter operator \"$bogusop\" …"}

Two contract breaks in one body — no `error.code` at all on a route whose
sibling rejections all speak the ADR-0112 catalogue, and the driver-internal
`[sql-driver]` prefix on the wire, which is what the objectstack-ai#3867 sanitiser exists to
stop.

Fixed at the throw site (PD objectstack-ai#12), not by teaching the REST layer to guess:
both drivers now refuse through an `unsupportedFilterError` helper that stamps
`code = StandardErrorCode.enum.INVALID_FILTER` — the constant, so a catalogue
rename breaks the compile — and `status = 400`. `INVALID_FILTER` is the same
code `metadata-protocol` already emits when a filter fails to parse upstream
(`malformedFilterArrayError` / `unusableFilterError`): one condition, one wire
code, however the caller reached it. The `status` also puts the rejection on
`isExpectedQueryRejection`, so a client mistake stops being logged as an
unhandled server error.

Applied to every filter-COMPILATION refusal in both backends, not only the one
branch the issue names: unsupported operator ($-object, legacy triple),
unrecognised logical keyword, unrecognised element type, and a `between` /
`$between` operand that is not a two-element array. They are the same envelope
defect on adjacent lines, and objectstack-ai#3948 made the two drivers agree that an
uncompilable filter is a refusal — so their refusal envelopes have to agree
too, or the cross-driver parity this repo relies on is false where it matters.

Tests: new `sql-driver-filter-refusal-envelope.test.ts` (8) and
`memory-filter-refusal-envelope.test.ts` (5) pin `code`, `status`, the absence
of the internal prefix, and that the actionable operator/field/vocabulary
detail survives. Full suites green: driver-sql 623 passed, driver-memory 286
passed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017gEHJN2NFpS9VMeURvakgD

* fix(metadata-protocol): PATCH/DELETE of a nonexistent record answer RECORD_NOT_FOUND, not 200 (objectstack-ai#4435)

The READ path was already honest — `getData` on an unknown id answers `404
RECORD_NOT_FOUND`. Both single-record WRITE paths reported success for a record
that does not exist:

  PATCH  /data/showcase_task/definitely_not_a_row  → 200 {"record":null}
  DELETE /data/showcase_task/definitely_not_a_row  → 200 {"success":true}

REST is a pass-through here (`res.json(await p.deleteData(...))`), so these are
the protocol's answers and this is where they are fixed.

What it cost: a client that PATCHed a concurrently deleted record was told the
write landed, and had to null-check a SUCCESS payload to find out otherwise;
`DELETE` said `success: true` for any string in the path, so a typo'd id, an
already-deleted row and a real deletion were indistinguishable — including in
bulk, where `deleteMany {"ids":["nonexistent_1"]}` answered `succeeded: 1`. It
is the same silent-no-op shape the v17 train removed everywhere else this
window (objectstack-ai#4240/objectstack-ai#4303/objectstack-ai#4315, objectstack-ai#4169, objectstack-ai#4190), one level up.

- `updateData` asks existence BEFORE the write, via the same `findOne` +
  caller context `getData` uses. Deliberately not a post-check on the returned
  row: the engine returns the post-write READBACK, which is also `null` when
  the row still exists but the write moved it out of the caller's row scope
  (reassigning `owner_id` away from yourself under an owner-scoped policy) —
  reading that as "not found" would 404 a write that succeeded.
- `deleteData` and `deleteManyData` read the driver's own answer. The contract
  (`IDataDriver.delete` — "True if deleted, false if not found") already
  carried it; the code discarded it and pushed a literal `success: true`.
  Read as `=== false` on purpose: that is the contract's positive not-found
  value, while a driver returning the deleted row or an off-contract
  `undefined` gives no such signal, and inventing a 404 from a falsy return
  would break deletes against third-party drivers instead of reporting
  honestly. `success` on the 200 now means what it says.
- The 404 envelope is extracted as `recordNotFoundError` so the read and the
  two write paths cannot drift apart again.

Note on the issue's second half: the spec's `DeleteDataResponseSchema` declares
`success`, not `deleted`, so the existing key is correct as-is and nothing
renames.

Tests: new `protocol.record-not-found.test.ts` (12) covers PATCH/DELETE/
deleteMany, the read/write agreement on the same id, delete-twice, mixed
batches, the `=== false` reading, and that the existence probe is asked with
the caller's context. Three `protocol.dropped-fields.test.ts` fixtures stubbed
`findOne → null` while PATCHing — under the new contract that IS a 404, so they
now describe an engine that has the row (they are about the strip channel, not
about missing records). Suites green: metadata-protocol 169, rest + objectql
unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017gEHJN2NFpS9VMeURvakgD

* fix(runtime): a sandbox capability denial is a 500 crash, not a 400 rejection (objectstack-ai#4431)

The `action-crash-vs-rejection` contract (objectstack-ai#3951) pins the table: a
`SandboxError` WITH `innerMessage` is a body's deliberate throw → 400; a
`SandboxError` with NO `innerMessage` — timeout, capability denial — is a crash
→ 500. Capability denials were answering 400:

  POST /api/v1/actions/showcase_task/rc1_crash_probe
  → 400 {"error":{"code":"VALIDATION_ERROR",
         "message":"SandboxError: capability 'api.read' not granted to action …"}}

Why: the gate throws `SandboxError` synchronously INSIDE a QuickJS host
function, which rejects the async IIFE inside the VM, so it returns through the
`__error` side-channel — and the pump loop presumed everything arriving there
was user code throwing on purpose, setting `innerMessage` unconditionally. The
dispatcher's classifier then read that as a deliberate rejection. So every
capability denial stayed invisible to gateway error rates, APM and alerting —
exactly the blindness objectstack-ai#3951 was written to close — and the client also received
the `SandboxError: ` debug prefix that belongs only in server logs.

`SandboxError`'s own jsdoc already said `innerMessage` is undefined for the
sandbox's internal errors; that only held for denials detected OUTSIDE
evaluation (a timeout, which takes the separate `budgetError` path). In-VM
host-call denials — `ctx.api.*`, `ctx.log`, `ctx.crypto`, `ctx.api.transaction`
— were misclassified.

Fix: the sandbox's own faults now carry a marker THROUGH the VM.
`hostErrorToVm` stamps `__objectstackSandboxFault` on any `SandboxError` it
marshals, and the synchronous gates throw the VM handle it builds rather than a
raw host error — quickjs-emscripten passes a thrown handle through verbatim
while its `newError` path copies only `name`/`message`, which is precisely how
the identity was lost. The reject handler reports the marker on the additive
`__errorInfo` channel, and the pump loop, seeing it, rethrows with neither the
`<kind> '<name>' threw:` wrapper (nothing threw — the sandbox refused) nor an
`innerMessage`. The existing classifier then does the rest: name is
`SandboxError`, no inner/code/fields ⇒ unexpected fault ⇒ `errorFromThrown(err,
500)`, and the message reaching the client is the capability text with the
debug prefix stripped.

A marker rather than a match on the flattened `SandboxError: …` text, because
the flattening is user-reachable: a body that CATCHES the denial and throws its
own business error must keep its 400, and that case is pinned.

No ADR or contract was changed — this makes the runtime deliver the contract
objectstack-ai#3951 already specifies.

Tests: new `sandbox/capability-denial-is-a-fault.test.ts` (7) covers all four
in-VM gates, the absence of innerMessage/code/fields, the prefix, the
caught-and-rethrown rejection, an ordinary deliberate throw, and that a record
`ValidationError` crossing `ctx.api` keeps its `code`/`fields` (the marker must
not turn every failed write into a 500). Verified failing on all four denial
cases before the fix. Runtime suite green: 73 files / 1033 tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017gEHJN2NFpS9VMeURvakgD

* chore: add changeset for the v17 REST envelope defects (objectstack-ai#4431, objectstack-ai#4435, objectstack-ai#4436, objectstack-ai#4483)

* fix(test): call syncSchema with its real (object, schema) signature (objectstack-ai#4436)

The objectstack-ai#4436 refusal-envelope test passed a single merged object where the
driver takes the object name as its own first argument, so the suite could
not type-check. Matches the idiom in the sibling memory-driver tests.

* fix(metadata-protocol): one probe per PATCH, and the existence gate is not an RLS gate (objectstack-ai#4435)

Follow-up to 959b838, fixing two defects the first cut introduced. Both were
caught by CI (`Test Core` on @objectstack/objectql, `Dogfood Regression Gate
1/2`), and the second is the more serious of the two.

## 1. The existence probe duplicated OCC's read

`updateData` called `assertVersionMatch` (which reads the row for its
`updated_at`) and then `assertRecordExists` (which reads the same row again).
Two round-trips per PATCH — a performance regression no gate reports — and the
`protocol-data.test.ts` OCC cases said so directly ("expected to be called
once, but got 2 times").

The two gates want the same row, so they now share one read: `probeRecord`
fetches it, `assertVersionOf` became a PURE comparison over an
already-read row, and `assertVersionMatch` survives only for `deleteData`,
which needs no existence probe at all — the driver's own return reports whether
a row matched, so a plain DELETE stays at zero extra reads and only an OCC
token buys one.

## 2. The probe must ask EXISTENCE, not the caller's visibility

The first cut probed with the CALLER's context, reasoning that it should match
`getData`. That quietly turned the existence gate into an authorization gate: a
row the caller cannot read comes back `null`, so the PATCH answers 404. Two
things break.

It moves an RLS decision out of the write policy. Whether an unreadable row may
be written by id is the objectstack-ai#1994 pre-image check's call, made inside
`engine.update`. A probe in front of it adds a second, different rule — scope
creep into the security model, out of a bug fix about missing records.

And it disarms a revert-provable security proof. `@proof: rls-by-id-write`
(`qa/dogfood/test/rls-fixture.dogfood.test.ts`, referenced by the
`permission.rowLevelSecurity.using` liveness ledger entry) boots a fixture whose
member can read nothing and has no write policy, and asserts the runner reports
`rls-hole` — the RED half that proves the gate can go red at all. A
caller-scoped probe 404s that PATCH and the proof goes green: if objectstack-ai#1994 were ever
reverted, this probe would MASK it. Accidentally hardening one path is not worth
permanently blinding the gate that watches the whole class.

So the probe runs as system and answers existence only. Authorization stays
exactly where it was, and the sole behaviour added is the 404 the issue asked
for: an id that names no row at all.

Tests: `protocol-data.test.ts`'s OCC block now asserts the new contract — one
probe on every PATCH (the existence probe, no OCC comparison without a token),
still exactly one when OCC IS requested (the anti-duplication pin), 404 before
any OCC verdict for a missing id, and DELETE without a token issuing no probe.
Its fixtures now supply a row, because under this contract a PATCH of an absent
record is correctly a 404 and those cases are about OCC. Two cases added to
`protocol.record-not-found.test.ts` pin the system-context probe and that an
unreadable-but-existing row still reaches the engine for RLS to decide.

Green: objectql protocol-data 117, metadata-protocol 170, dogfood shard 1/2
38 files / 235 passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017gEHJN2NFpS9VMeURvakgD

---------

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation protocol:data size/xl tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

REST 读路径:searchFields / groupBy / aggregations 指向不存在的字段时被静默降级(#4226 收口后剩下的三条轴)

1 participant